import {
  GlLink,
  GlAlert,
  GlCollapsibleListbox,
  GlFormInput,
  GlFormGroup,
  GlSprintf,
} from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import * as Sentry from '@sentry/browser';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SelectionSummary from 'ee/security_dashboard/components/shared/vulnerability_report/selection_summary.vue';
import eventHub from 'ee/security_dashboard/utils/event_hub';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import toast from '~/vue_shared/plugins/global_toast';
import { VULNERABILITY_STATE_OBJECTS, DISMISSAL_REASONS } from 'ee/vulnerabilities/constants';
import projectVulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/project_vulnerabilities.query.graphql';
import vulnerabilityResolve from 'ee/security_dashboard/graphql/mutations/vulnerability_resolve.mutation.graphql';
import vulnerabilitiesDismiss from 'ee/security_dashboard/graphql/mutations/vulnerabilities_dismiss.mutation.graphql';
import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';

const { dismissed, ...VULNERABILITY_STATE_OBJECTS_WITHOUT_DISMISSED } = VULNERABILITY_STATE_OBJECTS;

jest.mock('@sentry/browser');
jest.mock('~/vue_shared/plugins/global_toast');

Vue.use(VueApollo);

describe('Selection Summary component', () => {
  let wrapper;

  const createApolloProvider = (...queries) => {
    return createMockApollo([...queries]);
  };

  const findForm = () => wrapper.find('form');
  const findGlAlert = () => wrapper.findComponent(GlAlert);
  const findStatusListbox = () => wrapper.findByTestId('status-listbox');
  const findDismissalReasonListbox = () => wrapper.findByTestId('dismissal-reason-listbox');
  const findListboxItem = (id) => wrapper.findByTestId(`listbox-item-${id}`);
  const findCommentFormInput = () => wrapper.findComponent(GlFormInput);
  const findStatusFormGroup = () => wrapper.findByTestId('status-form-group');
  const findDismissalReasonFormGroup = () => wrapper.findByTestId('dismissal-reason-form-group');
  const findCommentFormGroup = () => wrapper.findByTestId('comment-form-group');
  const findCancelButton = () => wrapper.findByTestId('cancel-button');
  const findSubmitButton = () => wrapper.find('[type="submit"]');

  const selectStatus = (status) => {
    return findStatusListbox().vm.$emit('select', status);
  };

  const selectDismissalReason = (reason) => {
    return findDismissalReasonListbox().vm.$emit('select', reason);
  };

  const addComment = (comment) => {
    return findCommentFormInput().vm.$emit('input', comment);
  };

  const submitForm = async ({ state, comment, dismissalReason } = {}) => {
    if (state) {
      await selectStatus(state);
    }

    if (state === 'dismissed' && dismissalReason) {
      await selectDismissalReason(dismissalReason);
    }

    if (comment) {
      await addComment(comment);
    }

    return findForm().trigger('submit');
  };

  const createComponent = ({
    selectedVulnerabilities = [],
    apolloProvider,
    vulnerabilitiesQuery,
    vulnerabilitiesCountsQuery,
  } = {}) => {
    wrapper = shallowMountExtended(SelectionSummary, {
      apolloProvider,
      stubs: {
        GlAlert,
        GlSprintf,
        GlCollapsibleListbox,
        GlLink,
        GlFormGroup,
      },
      propsData: {
        selectedVulnerabilities,
      },
      provide: {
        vulnerabilitiesQuery,
        vulnerabilitiesCountsQuery,
      },
    });
  };

  describe('with 1 vulnerability selected', () => {
    beforeEach(() => {
      createComponent({ selectedVulnerabilities: [{ id: 'id_0' }] });
    });

    it('renders correctly', () => {
      expect(findForm().text()).toContain('1 Selected');
      expect(findStatusListbox().exists()).toBe(true);
      expect(findDismissalReasonListbox().exists()).toBe(false);
      expect(findCommentFormInput().exists()).toBe(false);
      expect(findCancelButton().exists()).toBe(true);
      expect(findSubmitButton().exists()).toBe(true);
      expect(findSubmitButton().attributes('disabled')).toBeUndefined();
      expect(findSubmitButton().classes('js-no-auto-disable')).toBe(true);
    });
  });

  describe('with multiple vulnerabilities selected', () => {
    beforeEach(() => {
      createComponent({ selectedVulnerabilities: [{ id: 'id_0' }, { id: 'id_1' }] });
    });

    it('renders correctly', () => {
      expect(findForm().text()).toContain('2 Selected');
    });
  });

  describe('status listbox', () => {
    beforeEach(() => {
      createComponent();
    });

    it('shows the placeholder text when no status is selected', () => {
      expect(findStatusListbox().props('toggleText')).toBe(
        SelectionSummary.i18n.statusTogglePlaceholder,
      );
    });

    it('shows expected items', () => {
      const states = Object.values(VULNERABILITY_STATE_OBJECTS);

      expect(findStatusListbox().props('items')).toHaveLength(states.length);

      states.forEach((state) => {
        const itemText = findListboxItem(state.state).text();
        expect(itemText).toContain(state.dropdownText);
        expect(itemText).toContain(state.dropdownDescription);
      });
    });

    it.each(Object.entries(VULNERABILITY_STATE_OBJECTS))(
      'shows the expected text in the listbox button when %s is clicked',
      async (_key, { state, dropdownText }) => {
        await selectStatus(state);

        expect(findStatusListbox().props('toggleText')).toBe(dropdownText);
      },
    );

    it('shows error message when submitting without selection', async () => {
      expect(findStatusFormGroup().attributes('state')).toBeDefined();

      await submitForm();

      expect(findStatusFormGroup().attributes('state')).toBeUndefined();
      expect(wrapper.emitted()['vulnerability-updated']).toBeUndefined();
    });

    it('clears error message when selecting', async () => {
      await submitForm();
      await selectStatus('dismissed');

      expect(findStatusFormGroup().attributes('state')).toBeDefined();
    });
  });

  describe('dismissal reason listbox', () => {
    beforeEach(() => {
      createComponent();
    });

    it.each(Object.keys(VULNERABILITY_STATE_OBJECTS_WITHOUT_DISMISSED))(
      'does not render after selecting status %s',
      async (state) => {
        await selectStatus(state);

        expect(findDismissalReasonListbox().exists()).toBe(false);
      },
    );

    it('renders after selecting status dismissed', async () => {
      expect(findDismissalReasonListbox().exists()).toBe(false);

      await selectStatus('dismissed');

      expect(findDismissalReasonListbox().exists()).toBe(true);
    });

    it('shows the placeholder text when no reason is selected', async () => {
      await selectStatus('dismissed');

      expect(findDismissalReasonListbox().props('toggleText')).toBe(
        SelectionSummary.i18n.dismissalReasonTogglePlaceholder,
      );
    });

    it('shows error message when submitting without selection', async () => {
      await selectStatus('dismissed');

      expect(findDismissalReasonFormGroup().attributes('state')).toBeDefined();

      await findForm().trigger('submit');

      expect(findDismissalReasonFormGroup().attributes('state')).toBeUndefined();
      expect(wrapper.emitted()['vulnerability-updated']).toBeUndefined();
    });

    it('clears error message when selecting', async () => {
      await submitForm({ state: 'dismissed' });
      await selectDismissalReason('false_positive');

      expect(findDismissalReasonFormGroup().attributes('state')).toBeDefined();
    });

    it('passes expected items', async () => {
      await selectStatus('dismissed');

      expect(findDismissalReasonListbox().props('items')).toEqual(
        Object.entries(DISMISSAL_REASONS).map(([reason, text]) => ({
          value: reason,
          text,
        })),
      );
    });

    it.each(Object.entries(DISMISSAL_REASONS))(
      'shows the expected text in the listbox button when %s is clicked',
      async (key, text) => {
        createComponent();

        await selectStatus('dismissed');
        await selectDismissalReason(key);

        expect(findDismissalReasonListbox().props('toggleText')).toBe(text);
      },
    );
  });

  describe('comment input', () => {
    beforeEach(() => {
      createComponent();
    });

    it.each(Object.keys(VULNERABILITY_STATE_OBJECTS))(
      'renders after selecting status %s',
      async (state) => {
        expect(findCommentFormInput().exists()).toBe(false);

        await selectStatus(state);

        expect(findCommentFormInput().exists()).toBe(true);
      },
    );

    it.each(Object.keys(VULNERABILITY_STATE_OBJECTS_WITHOUT_DISMISSED))(
      'passes comment placeholder for status %s',
      async (state) => {
        await selectStatus(state);

        expect(findCommentFormInput().attributes('placeholder')).toBe(
          SelectionSummary.i18n.commentPlaceholder,
        );
      },
    );

    it('passes required comment placeholder for status dismissed', async () => {
      await selectStatus('dismissed');

      expect(findCommentFormInput().attributes('placeholder')).toBe(
        SelectionSummary.i18n.requiredCommentPlaceholder,
      );
    });

    it('shows error message when submitting without comment for dismissed state', async () => {
      await selectStatus('dismissed');

      expect(findCommentFormGroup().attributes('state')).toBeDefined();

      await findForm().trigger('submit');

      expect(findCommentFormGroup().attributes('state')).toBeUndefined();
      expect(wrapper.emitted()['vulnerability-updated']).toBeUndefined();
    });

    it('clears error message when adding comment', async () => {
      await submitForm({ state: 'dismissed' });
      await addComment('test comment');

      expect(findCommentFormGroup().attributes('state')).toBeDefined();
    });
  });

  describe.each(Object.entries(VULNERABILITY_STATE_OBJECTS_WITHOUT_DISMISSED))(
    'state dropdown change - %s',
    (state, { action, mutation }) => {
      const selectedVulnerabilities = [
        { id: 'gid://gitlab/Vulnerability/54', vulnerabilityPath: '/vulnerabilities/54' },
        { id: 'gid://gitlab/Vulnerability/56', vulnerabilityPath: '/vulnerabilities/56' },
        { id: 'gid://gitlab/Vulnerability/58', vulnerabilityPath: '/vulnerabilities/58' },
      ];

      describe('when API call fails', () => {
        beforeEach(() => {
          const apolloProvider = createApolloProvider([
            mutation,
            jest.fn().mockRejectedValue({
              data: {
                [mutation.definitions[0].name.value]: {
                  errors: [
                    {
                      message: 'Something went wrong',
                    },
                  ],
                },
              },
            }),
          ]);

          createComponent({
            apolloProvider,
            selectedVulnerabilities,
          });
        });

        it(`does not emit vulnerability-updated event - ${action}`, async () => {
          await submitForm({ state });
          await waitForPromises();
          expect(wrapper.emitted()['vulnerability-updated']).toBeUndefined();
        });

        it(`shows alert - ${action}`, async () => {
          await submitForm({ state });
          await waitForPromises();

          expect(findGlAlert().text()).toMatchInterpolatedText(
            'Failed updating vulnerabilities with the following IDs: 54, 56, 58',
          );

          const links = wrapper.findAllComponents(GlLink);
          selectedVulnerabilities.forEach(({ vulnerabilityPath }, index) => {
            expect(links.at(index).attributes('href')).toBe(vulnerabilityPath);
          });
        });
      });

      describe('when API call is successful', () => {
        let apolloProvider;

        const requestHandler = jest.fn().mockResolvedValue({
          data: {
            [mutation.definitions[0].name.value]: {
              errors: [],
              vulnerability: {
                id: selectedVulnerabilities[0].id,
                [`${state}At`]: '2020-09-16T11:13:26Z',
                state: state.toUpperCase(),
                ...(state !== 'detected' && {
                  [`${state}By`]: {
                    id: 'gid://gitlab/User/1',
                  },
                }),
                stateTransitions: {
                  nodes: {
                    dismissalReason: state === 'dismissed' ? 'false_positive' : null,
                  },
                },
              },
            },
          },
        });

        beforeEach(() => {
          apolloProvider = createApolloProvider([mutation, requestHandler]);

          createComponent({
            apolloProvider,
            selectedVulnerabilities,
          });
        });

        it(`calls the mutation with the expected data and emits an update for each vulnerability - ${action}`, async () => {
          const mockComment = 'test comment';

          await submitForm({ state, comment: mockComment });
          await waitForPromises();
          selectedVulnerabilities.forEach((v, i) => {
            expect(wrapper.emitted()['vulnerabilities-updated'][i][0]).toEqual([v.id]);

            const mutationPayload = {
              id: v.id,
              comment: mockComment,
            };

            expect(requestHandler).toHaveBeenCalledWith(expect.objectContaining(mutationPayload));
          });

          expect(requestHandler).toHaveBeenCalledTimes(selectedVulnerabilities.length);
        });

        it('clears the apollo cache to ensure that previously made queries with status filters will be freshly fetched', async () => {
          const cacheClearSpy = jest.spyOn(apolloProvider.defaultClient, 'clearStore');

          expect(cacheClearSpy).not.toHaveBeenCalled();

          await submitForm({ state });
          await waitForPromises();

          expect(cacheClearSpy).toHaveBeenCalledTimes(1);
        });

        it(`calls the toaster - ${action}`, async () => {
          await submitForm({ state });
          await waitForPromises();
          // Workaround for the detected state, which shows as "needs triage" in the UI but uses
          // "detected" behind the scenes.
          const stateString =
            state === VULNERABILITY_STATE_OBJECTS.detected.state ? 'needs triage' : state;

          expect(toast).toHaveBeenLastCalledWith(`3 vulnerabilities set to ${stateString}`);
        });

        it(`the buttons are unclickable during form submission - ${action}`, async () => {
          const areElementsDisabled = () =>
            findSubmitButton().props('loading') &&
            findCancelButton().props('disabled') &&
            findStatusListbox().props('disabled') &&
            findCommentFormInput().attributes('disabled') === 'true';

          expect(findSubmitButton().props('disabled')).toBeDefined();

          await submitForm({ state });

          expect(areElementsDisabled()).toBe(true);

          await waitForPromises();

          expect(areElementsDisabled()).toBe(false);
        });

        it(`emits an event for the event hub - ${action}`, async () => {
          const spy = jest.fn();
          eventHub.$on('vulnerabilities-updated', spy);

          await submitForm({ state });
          await waitForPromises();

          expect(spy).toHaveBeenCalled();
        });
      });
    },
  );

  describe('bulk dismissal', () => {
    const selectedVulnerabilities = [
      { id: 'gid://gitlab/Vulnerability/54', vulnerabilityPath: '/vulnerabilities/54' },
      { id: 'gid://gitlab/Vulnerability/56', vulnerabilityPath: '/vulnerabilities/56' },
      { id: 'gid://gitlab/Vulnerability/58', vulnerabilityPath: '/vulnerabilities/58' },
    ];

    describe('when API call is successful', () => {
      let apolloProvider;

      const requestHandler = jest.fn().mockResolvedValue({
        data: {
          vulnerabilitiesDismiss: {
            errors: [],
            vulnerabilities: [
              {
                id: 'gid://gitlab/Vulnerability/54',
                state: 'DISMISSED',
                dismissedAt: 'now',
                dismissedBy: { id: '1' },
              },
            ],
          },
        },
      });

      beforeEach(() => {
        apolloProvider = createApolloProvider([vulnerabilitiesDismiss, requestHandler]);

        createComponent({
          apolloProvider,
          selectedVulnerabilities,
        });
      });

      it('calls the mutation with the expected data and emits an update for each vulnerability', async () => {
        expect(requestHandler).not.toHaveBeenCalled();

        await submitForm({
          state: 'dismissed',
          dismissalReason: 'false_positive',
          comment: 'test',
        });
        await waitForPromises();

        expect(requestHandler).toHaveBeenCalledTimes(1);
        expect(requestHandler).toHaveBeenCalledWith(
          expect.objectContaining({
            comment: 'test',
            dismissalReason: 'FALSE_POSITIVE',
            ids: selectedVulnerabilities.map((v) => v.id),
          }),
        );
      });

      it('calls the toaster', async () => {
        expect(toast).not.toHaveBeenCalled();

        await submitForm({
          state: 'dismissed',
          dismissalReason: 'false_positive',
          comment: 'test',
        });
        await waitForPromises();

        expect(toast).toHaveBeenLastCalledWith(
          `${selectedVulnerabilities.length} vulnerabilities set to dismissed`,
        );
      });

      it('emits "vulnerabilities-updated" for each selected vulnerability', async () => {
        const eventName = 'vulnerabilities-updated';

        expect(wrapper.emitted(eventName)).toBeUndefined();

        await submitForm({
          state: 'dismissed',
          dismissalReason: 'false_positive',
          comment: 'test',
        });
        await waitForPromises();

        const selectedVulnerabilitiesIds = selectedVulnerabilities.map((v) => v.id);
        const emittedEvents = wrapper.emitted(eventName);
        const firstEmittedEventPayload = emittedEvents[0][0];

        expect(emittedEvents).toHaveLength(1);
        expect(firstEmittedEventPayload).toEqual(selectedVulnerabilitiesIds);
      });

      it('clears the apollo cache to ensure that previously made queries with status filters will be freshly fetched', async () => {
        const cacheClearSpy = jest.spyOn(apolloProvider.defaultClient, 'clearStore');

        expect(cacheClearSpy).not.toHaveBeenCalled();

        await submitForm({
          state: 'dismissed',
          dismissalReason: 'false_positive',
          comment: 'test',
        });
        await waitForPromises();

        expect(cacheClearSpy).toHaveBeenCalledTimes(1);
      });
    });

    describe.each([
      {
        mutationHandler: jest.fn().mockResolvedValue({
          data: {
            vulnerabilitiesDismiss: {
              errors: ['First error message', 'Second error message'],
            },
          },
        }),
        expectedErrorMessage: 'First error message,Second error message',
      },
      {
        mutationHandler: jest.fn().mockRejectedValue(new Error('Error message')),
        expectedErrorMessage: 'Error message',
      },
    ])('when API call fails', ({ mutationHandler, expectedErrorMessage }) => {
      beforeEach(() => {
        const apolloProvider = createApolloProvider([vulnerabilitiesDismiss, mutationHandler]);

        createComponent({
          apolloProvider,
          selectedVulnerabilities,
          dismissMultipleVulnerabilities: true,
        });
      });

      it('shows alert', async () => {
        await submitForm({
          state: 'dismissed',
          dismissalReason: 'false_positive',
          comment: 'test',
        });
        await waitForPromises();

        expect(findGlAlert().text()).toMatchInterpolatedText(
          'Failed updating vulnerabilities with the following IDs: 54, 56, 58',
        );

        const links = wrapper.findAllComponents(GlLink);
        selectedVulnerabilities.forEach(({ vulnerabilityPath }, index) => {
          expect(links.at(index).attributes('href')).toBe(vulnerabilityPath);
        });
      });

      it('sends the error to Sentry', async () => {
        expect(Sentry.captureException).not.toHaveBeenCalled();

        submitForm({
          state: 'dismissed',
          dismissalReason: 'false_positive',
          comment: 'test',
        });
        await waitForPromises();

        expect(Sentry.captureException).toHaveBeenCalledTimes(1);
        expect(Sentry.captureException.mock.calls[0][0]).toEqual(new Error(expectedErrorMessage));
      });
    });
  });

  describe('refetch queries', () => {
    it('uses expected queries with refetchQueries', async () => {
      const selectedVulnerabilities = [{}, {}, {}];
      const requestHandler = jest.fn().mockResolvedValue({ data: { vulnerabilityResolve: {} } });

      createComponent({
        apolloProvider: createApolloProvider([vulnerabilityResolve, requestHandler]),
        selectedVulnerabilities,
        vulnerabilitiesQuery: projectVulnerabilitiesQuery,
        vulnerabilitiesCountsQuery: countsQuery,
      });

      await submitForm({
        state: 'resolved',
        comment: 'test',
      });

      expect(requestHandler).toHaveBeenCalledTimes(selectedVulnerabilities.length);
      expect(requestHandler).toHaveBeenCalledWith(
        expect.objectContaining({
          comment: 'test',
          id: undefined,
        }),
      );
    });
  });
});
